충성도 프로그램
◎ 현재 상황
- 연극을 외주로 받아서 공연하는 극단이 있다.
- 공연 요청을 받으면 연극의 장르와 관객 규모를 받아, 비용을 책정한다.
- 해당 극단은 비극과 희극만 공연한다.
- 공연료와 포인트의 개념이 있다.
- 포인트를 사용해 할인을 받을 수 있다.
{ "hamlet": {"name": "Hamlet", "type": "tragedy"}, "as-like": {"name": "As You Like It", "type": "comedy"}, "othello": {"name": "Othello", "type": "tragedy"}, }
[ { "customer": "Bigco", "performances": [ { "playID": "hamlet", "audience": 55 }, { "playID": "as-like", "audience": 35, }, { "playID": "othello", "audience": 40, }, ] } ]
function statement(invoice, plays) { let totalAmount = 0; let volumeCredits = 0; let result = `청구 내역 (고객명: ${invoice.customer})\n`; const format = new Intl.NumberFormat( "en-US", { "style": "currency", "currency": "USD", "minimumFractionDigits": 2 } ).format; for (let perf of invoice.performances) { const play = plays[perf.playID]; let thisAmount = 0; switch (play.type) { case "tragedy": thisAmount = 40000; // 초과 인원 추가금 if (perf.audience > 30) { thisAmount += 1000 * (perf.audience - 30); } break; case "comedy": thisAmount = 30000; // 초과 인원 추가금 if (perf.audience > 20) { thisAmount += 10000 + 500 * (perf.audience - 20); } thisAmount += 300 * perf.audience; break; default: throw new Error(`알 수 없는 장르: ${play.type}`); } volumeCredits += Math.max(perf.audience - 30, 0); if ("comedy" == play.type) { volumeCredits += Math.floor(perf.audience / 5); } result += ` ${play.name}: ${format(thisAmount/100)} (${perf.audience}석)\n`; totalAmount += thisAmount; } result += `총액: ${format(totalAmount/100)}\n`; result += `적립 포인트: ${volumeCredits}점\n`; return result; }
청구 내역 (고객명: Bigco) Hamlet: $650.00 (55석) As You Like It: $580.00 (35석) Othello: $500.00 (40석) 총액: $1,730.00 적립 포인트: 47점
◎ 요구 사항
- 청구 내역을 HTML로 출력하여 보여줘야 한다.
- 배우는 더 많은 장르를 연기하고 싶어 한다. 그에 따른 공연료와 적립 포인트의 계산법의 변동이 생길 수 있다.
최선일까?
실무를 하다보면 딜레마에 빠지곤 한다. 처음부터 변경에 대비한 구조를 갖춘 코드를 작성하면 좋겠지만, 동작하는 코드를 작성하는 것에 집중한다. 위의 예시 코드도 같다. 요구사항에 맞게 잘 작동하고 있다. 하지만 변경에 취약하다. 수정 사항을 반영하기 힘든 구조를 가지고 있다.
프로그램이 새로운 기능을 추가하기에 편한 구조가 아니라면, 먼저 기능을 추가하기 쉬운 형태로 리팩터링하고 나서 원하는 기능을 추가한다.
책에서는 위와 같이 말한다. 개인적인 생각으로는 동작하는 코드가 먼저다. 그리고 기능을 추가하려는 시점에 수정하기 편한 리팩터링을 하는게 맞다.
리팩터링의 첫 단계
모든 리팩터링의 시작은 ‘테스트 코드’다. 테스트 코드를 작성하는 데 시간이 좀 걸리지만, 신경써서 만들어두면 디버깅 시간이 줄어, 전체 작업시간은 오히려 단축된다. 또한 코드의 변경에 따른 사이드 이펙트가 없는지 보장해주는 수단이기도 하다.
리팩터링하기 전에 제대로 된 테스트부터 마련한다. 테스트는 반드시 자가진단하도록 만든다.
◎ 공연 요금 계산 리팩터링
공연 요금 계산 함수 추출하기
function amountFor(aPerformance, play) { // 값이 바뀌지 않는 perf, play 는 매개 변수로 전달 let result = 0; // [주의]변수를 초기화 하는 경우는 내부 변수로 사용 switch(play.type) { case "tragedy": result = 40000; if (aPerformance.audience > 30) { result += 1000 * (aPerformance.audience - 30); } break; case "comedy": result = 30000; if (aPerformance.audience > 20) { result += 10000 + 500 * (aPerformance.audience - 20); } result += 300 * aPerformance.audience; break; default: throw new Error(`알 수 없는 장르: ${play.type}`); } return result; }
임시 변수를 질의 함수로 바꾸기
function playFor(aPerformance) { return plays[aPerformance]; }
필요하지 않은 변수 제거
function amountFor(aPerformance) { // 필요하지 않은 play 매개 변수 제거 let result = 0; switch(playFor(aPerformance).type) { case "tragedy": result = 40000; if (aPerformance.audience > 30) { result += 1000 * (aPerformance.audience - 30); } break; case "comedy": result = 30000; if (aPerformance.audience > 20) { result += 10000 + 500 * (aPerformance.audience - 20); } result += 300 * aPerformance.audience; break; default: throw new Error(`알 수 없는 장르: ${playFor(aPerformance).type}`); } return result; }
변수 인라인하기
function statement(invoice, plays) { ... for (let perf of invoice.performances) { const play = playFor(perf) let thisAmount = amountFor(perf, play > playFor(perf) > 제거); ... result += ( `${playFor(perf).name}: ${format(amountFor(perf)/100)} (${perf.audience}석)\n` ); totalAmount = amountFor(perf); } ... }
한 번에 여러 개를 수정하기 보다는 작은 단위로 수정하는 것이 좋고, 바로 컴파일 혹은 테스트 해서 실수가 없는지 확인하는 것이 좋다. 이러한 습관은 디버깅의 편의를 제공한다(쉽게 버그를 찾을 수 있다)
amountFor
함수 내의 변수명은 더 명확해졌다.- 함수의 반환 값에는 result를 사용한다.
- 매개변수의 역할이 뚜렷하지 않을 때는 부정 관사(a/an)을 사용한다.
- 임시 변수를 질의 함수로 바꾼다.
- 변수 인라인을 적용한다.
- 지역 변수(play)를 제거한 결과 적립 포인트 계산 부분을 추출하기 훨씬 쉬워졌다.
◎ format 임시변수 제거하기
function usd(aNumber) { return new Intl.NumberFormat( "en-US", { "style": "currency", "currency": "USD", "minimumFractionDigits": 2 } ).format(aNumber/100); }
- 임시 변수로 쓰이는 format을 함수로 추출했다.
- 함수 이름을 더 명확히 변경했다. 함수의 이름을 잘 지으면 읽지 않아도 내용을 알 수 있다.
◎ 적립 포인트 계산 함수 리팩터링
반복문 쪼개기 & 문장 슬라이스하기
function statement(invoice, plays) { let volumeCredits = 0; // 문장 슬라이스 ... for (let perf of invoice.performances) { ... } let volumeCredits = 0; // 문장을 슬라이스하여 변수 선언을 아래로 이동 for (let perf of invoice.performances) { // 값 누적 로직을 반복문 쪼개기로 분리 volumeCredits += volumeCreditsFor(perf); } }
- 반복문이 중복되는 것을 꺼리지 마라. 어느 정도의 중복은 성능의 차이를 거의 느끼지 못한다.
- 리팩터링으로 인한 성능 저하는 특별한 경우가 아니라면 무시하자. 리팩터링을 마무리하고나서 개선하자.
적립 포인트 계산 함수 추출하기
function volumeCreditsFor(aPerformance) { let result = 0; result += Math.max(aPerformance.audience - 30, 0); if ("comedy" == playFor(aPerformance).type) { result += Math.max(aPerformance.audience / 5); } return result; }
전체 포인트 계산 함수 추출하기
function totalVolumeCredits() { let result = 0; for (let perf of invoice.performances) { result += volumeCreditsFor(perf); } return result; }
function statement(invoice, plays) { ... for (let perf of invoice.performances) { ... } let volumeCredits = 0; for (let perf of invoice.performances) { volumeCredits += volumeCreditsFor(perf); } let volumeCredits = totalVolumeCredits(); ... }
변수 인라인하기
function statement(invoice, plays) { let totalAmount = 0; let result = `청구 내역 (고객명: ${invoice.customer})\n`; for (let perf of invoice.performances) { // 청구 내역을 출력 result += ( `${playFor(perf).name}: ${usd(amountFor(perf))} (${perf.audience}석)\n` ); totalAmount += amountFor(perf); } result += `총액: ${usd(totalAmount)}\n`; result += `적립 포인트: ${totalVolumeCredits()}점\n`; return result; }
◎ 총액 계산 함수 리팩터링
총액 계산 함수 추출하기
function totalAmount() { let result = 0; for (let perf of invoice.performances) { result += amountFor(perf); } return result; }
function statement(invoice, plays) { let totalAmount = 0; // 1. 변수 슬라이스 let result = `청구 내역 (고객명: ${invoice.customer})\n`; for (let perf of invoice.performances) { result += ( `${playFor(perf).name}: ${usd(amountFor(perf))} (${perf.audience}석)\n` ); totalAmount += amountFor(perf); } let totalAmount = totalAmount(); // 2. 슬라이스한 변수를 아래에 정의; result += `총액: ${usd(totalAmount())}\n`; // 3. 함수 인라인 result += `적립 포인트: ${totalVolumeCredits()}점\n`; return result; }
중간 점검: 난무하는 중첩 함수
statement()
함수의 역할은 출력할 문장을 생성하는 일이며, 계산 로직은 보조 함수로 분리했다. 계산 과정과 전체 흐름을 이해하기가 쉬워졌다.function statement(invoice, plays) { let result = `청구 내역 (고객명: ${invoice.customer})\n`; for (let perf of invoice.performances) { result += ( `${playFor(perf).name}: ${usd(amountFor(perf))} (${perf.audience}석)\n` ); totalAmount += amountFor(perf); } result += `총액: ${usd(totalAmount())}\n`; result += `적립 포인트: ${totalVolumeCredits()}점\n`; return result; function totalAmount() { let result = 0; for (let perf of invoice.performances) { result += amountFor(perf); } return result; } function totalVolumeCredits() { let result = 0; for (let perf of invoice.performances) { result += volumeCreditsFor(perf); } return result; } function usd(aNumber) { return new Intl.NumberFormat( "en-US", { "style": "currency", "currency": "USD", "minimumFractionDigits": 2 } ).format(aNumber/100); } function volumeCreditsFor(aPerformance) { let result = 0; result += Math.max(aPerformance.audience - 30, 0); if ("comedy" == playFor(aPerformance).type) { result += Math.max(aPerformance.audience / 5); } return result; } function playFor(aPerformance) { return plays[aPerformance]; } function amountFor(aPerformance) { let result = 0; switch(playFor(aPerformance).type) { case "tragedy": result = 40000; if (aPerformance.audience > 30) { result += 1000 * (aPerformance.audience - 30); } break; case "comedy": result = 30000; if (aPerformance.audience > 20) { result += 10000 + 500 * (aPerformance.audience - 20); } result += 300 * aPerformance.audience; break; default: throw new Error(`알 수 없는 장르: ${playFor(aPerformance).type}`); } return result; } }
계산 단계와 표현 단계 분리하기
지금까지 프로그램의 논리적 요소를 파악하기 쉽도록 코드를 구조화하는 것에 주안점을 두고 리팩터링했다. 다음 단계는
statement()
함수를 HTML로 만드는 작업이 필요하다. 한 가지 문제점이 있다. statement()
함수에 모든 계산 함수가 중첩 함수로 작성되어 있다는 것이다. 물론 모든 함수에 계산 함수를 복사/붙여넣기 하면 되지만, 그건 원하던 방식이 아니다.해결책은 ‘단계 쪼개기’다.
statement()
의 로직을 ‘데이터 처리’ 와 ‘HTML 표현’으로 분리한다. 단계를 쪼개려면 먼저 리팩터링한 코드를 각 단계에 알맞게 ‘함수 추출하기‘ 과정을 거쳐야 한다.◎ 함수 분리하기
function statement(invoice, plays) { // 계산 단계 return renderPlainText(invoice, plays); } function renderPlainText(invoice, plays) { // 표현 단계 ... }
function statement(invoice, plays) { const statementData = {}; // 중간 데이터 임시 생성 return renderPlainText(statementData, invoice, plays); // 중간 데이터를 인수로 전달 } function renderPlainText(data, invoice, plays) { ... }
function statement(invoice, plays) { const statementData = {}; statementData.customer = invoice.customer; // 고객 데이터를 중간 데이터로 옮김 return renderPlainText(statementData, invoice, plays); } function renderPlainText(data, invoice, plays) { let result = `청구 내역 (고객명: ${data.customer})\n`; // 중간 데이터에서 고객 데이터 얻음 ... }
function statement(invoice, plays) { const statementData = {}; statementData.customer = invoice.customer; statementData.performances = invoice.performances; // 공연 정보 데이터를 중간 데이터로 옮김 return renderPlainText(statementData, invoice, plays); // invoice 객체를 전달할 필요가 없어짐 } function renderPlainText(data, plays) { let result = `청구 내역 (고객명: ${data.customer})\n`; for (let perf of data.performances) { // 공연 정보를 중간 데이터에서 얻음 ... }
function statement(invoice, plays) { const statementData = {}; statementData.customer = invoice.customer; statementData.performances = invoice.performances.map(enrichPerformance); return renderPlainText(statementData, plays); function enrichPerformance(aPerformance) { const result = Object.assign({}, aPerformance); // 얕은 복사 수행 return result; } } function renderPlainText(data, plays) { ... }
function statement(invoice, plays) { const statementData = {}; statementData.customer = invoice.customer; statementData.performances = invoice.performances.map(enrichPerformance)); return renderPlainText(statementData, plays); function enrichPerformance(aPerformance) { const result = Object.assign({}, aPerformance); // 얕은 복사 수행 result.play = playFor(result); //중간 데이터에 연극 정보를 저장 return result; function playFor(aPerformance) { // renderPlainText의 playFor함수 이동 return plays[aPerformance.playID]; } } } function renderPlainText(data, plays) { let result = `청구 내역 (고객명: ${data.customer})\n`; for (let perf of data.performances) { result += ( `${playFor(perf).name}: ${usd(amountFor(perf))} (${perf.audience}석)\n` `${perf.play.name}: ${usd(amountFor(perf))} (${perf.audience}석)\n` ); totalAmount += amountFor(perf); } ... }
function statement(invoice, plays) { const statementData = {}; statementData.customer = invoice.customer; statementData.performances = invoice.performances.map(enrichPerformance); statementData.totalAmount = totalAmount(StatementData); statementData.totalVolumeCredits = totalVolumeCredits(StatementData); return renderPlainText(statementData, plays); function enrichPerformance(aPerformance) {...} function totalAmount(data) {...} function totalVolumeCredits(data) {...} } function renderPlainText(data, plays) { ... }
function statement(invoice, plays) { ... function totalAmount(data) { return data.performances.reduce( // for 루프를 파이프라인으로 바꿈 (total, p) => total + p.amount, 0 ); } function totalVolumeCredits(data) { return data.performances.reduce( // for 루프를 파이프라인으로 바꿈 (total, p) => total + p.volumeCredits, 0 ); }
function statement(invoice, plays) { return rederPlainText(createStatementData(invoice, plays)); function createStatementData(invoice, plays) { const statementData = {}; statementData.customer = invoice.customer; statementData.performances = invoice.performances.map(enrichPerformance); statementData.totalAmount = totalAmount(StatementData); statementData.totalVolumeCredits = totalVolumeCredits(StatementData); return statementData; } function renderPlainText(data, plays) { ... }
◎ 파일로 분리하기
export default function createStatementData(invoice, plays) { const result = {}; result.customer = invoice.customer; result.performances = invoice.performances.map(enrichPerformance); result.totalAmount = totalAmount(result); result.totalVolumeCredits = totalVolumeCredits(result); return result; function enrichPerformance(aPerformance) {...} function playFor(aPerformance) {...} function amountFor(aPerformance) {...} function volumeCreditsFor(aPerformance) {...} function totalAmount(data) {...} function totalVolumeCredits(data) {...} }
import createStatementData from './createStatementData.js'; function htmlStatement(invoice, plays) { return renderHtml(createStatementData(invoice, plays)); } function renderHtml(data) { let result = `<h1>청구 내역 (고객명: ${data.customer})</h1>\n`; result += "<table>\n"; ... }
중간 점검: 두 파일(과 두 단계)로 분리
import createStatementData from './createStatementData.js'; function statement(invoice, plays) { return renderPlainText(createStatementData(invoice, plays)); } function renderPlainText(data, plays) { let result = `청구 내역 (고객명: for ${data.customer})`; for (let perf of data.performances) { result += ` ${perf.play.name}: ${usd(perf.amount)} (${perf.audience}석)`; } result += `총액: ${usd(data.totalAmount)}\n`; result += `적립 포인트: ${data.totalVolumeCredits}점\n`; return result; } function htmlStatement(invoice, plays) { return renderHtml(createStatementData(invoice, plays)); } function renderHtml(data) { let result = `<h1>청구 내역 ${data.customer}</h1>\n` result += "<table>\n" result += "<tr><th>연극</th><th>좌석수</th><th>금액</th></tr>"; for (let perf of data.performances) { result += ` <tr><td>${perf.play.name}</td><td>${perf.audience}석</td></tr>`; result += `<td>${usd(perf.amount)}</td></tr>\n`; } result += `</table>\n` ... return result; } function usd(aNumber) {...}
export default function createStatementData(invoice, plays) { const result = {}; result.customer = invoice.customer; result.performances = invoice.performances.map(enrichPerformance); result.totalAmount = totalAmount(result); result.totalVolumeCredits = totalVolumeCredits(result); return result; function enrichPerformance(aPerformance) {...} function playFor(aPerformance) {...} function amountFor(aPerformance) {...} function volumeCreditsFor(aPerformance) {...} function totalAmount(data) {...} function totalVolumeCredits(data) {...} }
처음보다 코드량이 많이 늘었지만, 각 로직을 구성하는 요소가 더 명확해졌다. 이렇게 모듈화하면 전체 과정을 파악하기 더 쉬워진다.
간결함보다 명료함이 소프트웨어의 정수다
항시 코드베이스를 작업 시작 전보다 건강하게 만들어놓고 떠나야 한다.
다형성을 활용해 계산 코드 재구성하기
amountFor()
함수를 보면 내부에 조건문 로직이 있다. 연극 장르를 추가하고, 장르마다 공연료와 적립 포인트 계산이 다르게 지정될 때마다 조건문 로직을 수정해야 한다. 수정 횟수가 늘어날수록 코드는 골칫거리가 되기 쉽다. 이러한 경우 객체지향의 핵심인 다형성을 활용하는 것이 자연스럽다.조건부 로직을 다형성으로 바꾸기
상속 계층을 구성하고, 희극과 비극 서브클래스가 각자의 구체적인 계산 로직을 정의하는 것이다. 우선 상속 계층부터 정의해야 한다. 즉, 공연료와 적립 포인트 계산 함수를 담을 클래스가 필요하다.
공연기 계산기 만들기
핵심은 각 공연의 정보를 중간 데이터 구조에 채워주는
enrichPerformance()
함수다. 이 함수는 amountFor()
과 volumeCreditsFor()
함수를 호출하여 공연료와 적립 포인트를 계산한다. 두 함수를 전용 클래스로 옮기는 작업을 한다.function enrichPerformance(aPerformance) { const calculator = new PerformanceCalulator(aPerformance); const result = Object.assign({}, aPerformance); result.play = playFor(result); result.amount = amountFor(result); result.volumeCredits = volumeCreditsFor(result); return result }
class PerformanceCalculator { constructor(aPerformance) { this.performance = aPerformance; } }
함수 선언 바꾸기
function enrichPerformance(aPerformance) { const calculator = new PerformanceCalulator(aPerformance, playFor(aPerformance)); const result = Object.assign({}, aPerformance); result.play = calculator.play; result.amount = amountFor(result); result.volumeCredits = volumeCreditsFor(result); return result }
class PerformanceCalculator { constructor(aPerformance, aPlay) { this.performance = aPerformance; this.play = aPlay; } }
함수들을 계산기로 옮기기
class PerformanceCalculator { constructor(aPerformance, aPlay) { this.performance = aPerformance; this.play = aPlay; } get amount() { let result = 0; switch(this.play.type) { case "tragedy": ... case "comedy": ... default: ... } return result; } }
export default function createStatementData(invoice, plays) { ... function amountFor(aPerformance) { return new PerformanceCalculator(aPerformance, playFor(aPerformance)).amount; } }
function enrichPerformance(aPerformance) { const calculator = new PerformanceCalulator(aPerformance, playFor(aPerformance)); const result = Object.assign({}, aPerformance); result.play = calculator.play; result.amount = calculator.amount; result.volumeCredits = volumeCreditsFor(result); return result }
공연료 계산기를 다형성 버전으로 만들기
아직 다형성을 적용하지 않았다. 처음 해야할 것은 타입 코드를 서브 클래스로 바꾸는 것이다.
PerformanceCalculator
에서 서브 클래스를 정의하고, createStatementData()
에서 서브클래스를 사용하게 만든다. 서브클래스를 사용하려면 생성자 대신 함수를 호출하도록 바꿔야 한다. 생성자가 서브클래스의 인스턴스를 반환할 수 없기 떄문이다. 그래서 생성자를 팩터리 함수로 바꿔야 한다.function enrichPerformance(aPerformance) { const calculator = cretePerformanceCalculator(aPerfomance, playFor(aPerformance)); const result = Object.assign({}, aPerformance); result.play = calculator.play; result.amount = calculator.amount; result.volumeCredits = calculator.volumeCredits; return result; }
function createPerfomanceCalculator(aPerformance, aPlay) { return new PerformanceCalculator(aPerformance, aPlay); }
function createPerfomanceCalculator(aPerformance, aPlay) { switch(aPlay.type) { case "tragedy": return new TraedyCalculator(aPerformance, aPlay); case "comedy": return new ComedyCalculator(aPerformance, aPlay); default: ... } } class TragedyCalculator extend PerformanceCalculator {} class ComedyCalculator extend PerformanceCalculator {}
class PerformanceCalculator { construnctor(aPerformance, aPlay) { this.performance = aPerformance; this.play = aPlay; } get amount() { throw new Error("서브 클래스에서 정의"); } get volumeCredits() { return Math.max(this.performance.audience - 30, 0); } }
다형성을 활용하여 데이터 생성하기
export default function createStatementData(invoice, plays) { const result = {}; result.custormer = invoice.customer; result.performances = invoice.performances.map(enrichPerformance); result.totalAmount = totalAmount(result); result.totalVolumeCredits = totalVolumeCredits(result); return result; function enrichPerformance(aPerformance) { const calculator = cretePerformanceCalculator(aPerfomance, playFor(aPerformance)); const result = Object.assign({}, aPerformance); result.play = calculator.play; result.amount = calculator.amount; result.volumeCredits = calculator.volumeCredits; return result; } function playFor(aPerformance) { return plays[aPerformance.playID]; } function totalAmount() { return data.performances.reduce((total, p) => total + p.amount, 0); } function totalVolumeCredits(data) { return data.performances.reduce((total, p) => total + p.volumeCredits, 0); } } function createPerformanceCalculator(aPerformance, aPlay) { switch(aPlay.type) { case "tragedy": return new TraedyCalculator(aPerformance, aPlay); case "comedy": return new ComedyCalculator(aPerformance, aPlay); default: ... } } class PerformanceCalculator { construnctor(aPerformance, aPlay) { this.performance = aPerformance; this.play = aPlay; } get amount() { throw new Error("서브 클래스에서 정의"); } get volumeCredits() { return Math.max(this.performance.audience - 30, 0); } } class TragedyPerformanceCalculator { get amount() { let result = 40000; if (this.performance.audience > 30) { result += 1000 * (this.performance.audience - 30); } return result; } } class ComedyPerformanceCalculator { get amount() { let result = 30000; if (this.performance.audience > 20) { result += 10000 + 500 * (this.performance.audience - 20); } result += 300 * this.performance.audience; return result; } get volumeCredits() { return super.volumeCredits + Math.floor(this.performance.audience / 5); } }
다형성을 적용하면서 나아진 점은 연극 장르별 계산 코드들을 함께 묶어뒀다는 것이다. 앞으로의 수정이 대부분 장르의 추가와 연극료, 포인트 적립 정책의 변경이라면 이렇게 명확하게 분리해두는 것이 좋다. 수정과 추가가 쉬워졌다.
정리
리팩터링의 크게 세 단계로 진행했다.
- 원본 함수를 중첩 함수 여러개로 나눴다.
- 계산 코드와 출력 코드를 분리했다.
- 계산 로직을 다형성으로 표현했다.
좋은 코드를 가늠하는 확실한 방법은 ‘얼마나 수정하기 쉬운가’다.
댓글